from uuid import uuid4
import json
import json

from fastapi import APIRouter, Depends, BackgroundTasks
from fastapi.responses import StreamingResponse
from sqlalchemy import or_, select, desc
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.deps import get_current_user, get_db
from app.models.ai_session import AISession
from app.models.user import User
from app.models.user_preference import UserPreference
from app.models.anniversary import Anniversary
from app.models.memory_node import MemoryNode
from app.models.memory_edge import MemoryEdge
from app.schemas import ai as ai_schema
from app.api.response import success_response
from app.services.ai_agent_service import love_notes_agent
from app.services.langchain_service import langchain_service
from app.core.redis_client import get_redis
from datetime import datetime, timedelta, timezone
from app.core.config import settings
from app.db.session import async_session_factory

router = APIRouter()

MAX_HISTORY_MESSAGES = 200
HISTORY_KEEP_MESSAGES = 150

import re as _re

# 强触发：命中时自动“直接写入”而不是仅提案
_AUTO_COMMIT_RE = _re.compile(r"(提醒我|记得|闹钟|我不喜欢|我喜欢|我叫|我的生日)")

def _should_auto_commit(text: str | None) -> bool:
  if not text:
    return False
  return bool(_AUTO_COMMIT_RE.search(str(text)))

# 直觉规则：从单条用户输入中快速提取"我喜欢/不喜欢/提醒我"等，作为兜底
_RE_LIKE = _re.compile(r"我喜欢(?P<item>[^，。,.\s]{1,12})")
_RE_DISLIKE = _re.compile(r"我不喜欢(?P<item>[^，。,.\s]{1,12})|我(不吃|讨厌|过敏)(?P<item2>[^，。,.\s]{1,12})")
# 伴侣主体：女朋友/对象/伴侣/她/他/ta
_RE_P_LIKE = _re.compile(r"(女朋友|对象|伴侣|她|他|ta|TA).{0,4}喜欢(?P<item>[^，。,.\s]{1,12})")
_RE_P_DISLIKE = _re.compile(r"(女朋友|对象|伴侣|她|他|ta|TA).{0,4}(不喜欢|不吃|讨厌|过敏)(?P<item>[^，。,.\s]{1,12})")
_RE_P_PRON_LIKE = _re.compile(r"(她|他|ta|TA).{0,2}喜欢(?P<item>[^，。,.\s]{1,12})")
_RE_P_PRON_DISLIKE = _re.compile(r"(她|他|ta|TA).{0,2}(不喜欢|不吃|讨厌|过敏)(?P<item>[^，。,.\s]{1,12})")
_RE_REMIND = _re.compile(r"(提醒我|记得)(?P<text>[^。\n\r]{2,40})")

def _quick_extract_from_text(text: str | None, *, assume_partner_pronoun: bool = False) -> dict:
  out: dict = {}
  if not text:
    return out
  t = str(text)
  likes: list[str] = []
  dislikes: list[str] = []
  p_likes: list[str] = []
  p_dislikes: list[str] = []
  for m in _RE_LIKE.finditer(t):
    it = (m.group('item') or '').strip()
    if it:
      likes.append(it)
  for m in _RE_DISLIKE.finditer(t):
    it = (m.group('item') or m.group('item2') or '').strip()
    if it:
      dislikes.append(it)
  for m in _RE_P_LIKE.finditer(t):
    it = (m.group('item') or '').strip()
    if it:
      p_likes.append(it)
  for m in _RE_P_DISLIKE.finditer(t):
    it = (m.group('item') or '').strip()
    if it:
      p_dislikes.append(it)
  if assume_partner_pronoun:
    for m in _RE_P_PRON_LIKE.finditer(t):
      it = (m.group('item') or '').strip()
      if it:
        p_likes.append(it)
    for m in _RE_P_PRON_DISLIKE.finditer(t):
      it = (m.group('item') or '').strip()
      if it:
        p_dislikes.append(it)
  if likes or dislikes:
    out['prefs'] = {}
    if likes: out['prefs']['likes'] = list(dict.fromkeys(likes))
    if dislikes: out['prefs']['dislikes'] = list(dict.fromkeys(dislikes))
  if p_likes or p_dislikes:
    mem = out.setdefault('memories', {})
    prefs_list = mem.setdefault('preferences', [])
    for it in list(dict.fromkeys(p_likes)):
      prefs_list.append({'subject': 'partner', 'polarity': 'like', 'item': it})
    for it in list(dict.fromkeys(p_dislikes)):
      prefs_list.append({'subject': 'partner', 'polarity': 'dislike', 'item': it})
  m = _RE_REMIND.search(t)
  if m:
    txt = (m.group('text') or '').strip()
    if txt:
      out['memories'] = {'reminders': [{'text': txt, 'due': ''}]}
  return out

async def _sync_quick_memory(*, user_id: int, relationship_id: int | None, data: dict) -> None:
  if not data:
    return
  async with async_session_factory() as session:
    await _sync_memory_graph(session=session, user_id=user_id, relationship_id=relationship_id, data=data)


def _update_partner_referent(context: dict, message: str | None) -> tuple[dict, bool]:
  """维护会话级的“伴侣指代”缓存。
  - 若本条消息出现“女朋友/伴侣/对象”，设置 partner_pronoun_messages=5
  - 每处理一条消息，计数自动衰减
  - 返回 (更新后的上下文, 本条消息是否应将 她/他 视为伴侣)
  """
  ctx = dict(context or {})
  refer = dict((ctx.get('referents') or {}))
  count = int(refer.get('partner_pronoun_messages') or 0)
  until_ts = float(refer.get('partner_pronoun_until_ts') or 0.0)
  now_ts = datetime.now(timezone(timedelta(hours=8))).timestamp()
  explicit = bool(_re.search(r"(女朋友|对象|伴侣)", message or ""))
  if explicit:
    count = 5
    # 10 分钟有效期
    until_ts = max(until_ts, now_ts + 10 * 60)
  assume = (count > 0) or (until_ts > now_ts)
  # 消费一次
  if count > 0:
    count -= 1
  refer['partner_pronoun_messages'] = count
  refer['partner_pronoun_until_ts'] = until_ts
  ctx['referents'] = refer
  return ctx, assume


# ====== 提案存储（Redis） ======
import json as _json

def _proposal_key(user_id: int, relationship_id: int | None) -> str:
  tz = timezone(timedelta(hours=8))
  d = datetime.now(tz).date().strftime('%Y%m%d')
  return f"ai:mem:proposal:{d}:uid:{user_id}:rid:{relationship_id or 0}"

async def _merge_proposals(*, user_id: int, relationship_id: int | None, data: dict) -> None:
  """将新提取的记忆信息以计数的方式合并到 Redis 提案中。"""
  redis = get_redis()
  if not redis:
    return
  key = _proposal_key(user_id, relationship_id)
  raw = await redis.get(key)
  base = {}
  if raw:
    try:
      base = _json.loads(raw)
    except Exception:
      base = {}

  def _bump_list(lst: list[str], bucket: dict):
    for it in lst:
      if not isinstance(it, str) or not it.strip():
        continue
      bucket[it] = int(bucket.get(it, 0)) + 1

  prefs = (data.get('prefs') or {}) if isinstance(data, dict) else {}
  mems = (data.get('memories') or {}) if isinstance(data, dict) else {}

  out = {
    'likes': base.get('likes') or {},
    'dislikes': base.get('dislikes') or {},
    'topics': base.get('topics') or {},
    'reminders': base.get('reminders') or {},
    'agreements': base.get('agreements') or {},
    'boundaries': base.get('boundaries') or {},
  }

  _bump_list(list(prefs.get('likes') or []), out['likes'])
  _bump_list(list(prefs.get('dislikes') or []), out['dislikes'])
  _bump_list(list(prefs.get('topics') or []), out['topics'])

  for r in (mems.get('reminders') or []):
    if isinstance(r, dict):
      txt = str(r.get('text') or '').strip()
      if txt:
        out['reminders'][txt] = int(out['reminders'].get(txt, 0)) + 1
  for a in (mems.get('agreements') or []):
    if isinstance(a, dict):
      txt = str(a.get('text') or '').strip()
      if txt:
        out['agreements'][txt] = int(out['agreements'].get(txt, 0)) + 1
  for b in (mems.get('boundaries') or []):
    if isinstance(b, dict):
      it = str(b.get('item') or '').strip()
      if it:
        out['boundaries'][it] = int(out['boundaries'].get(it, 0)) + 1

  await redis.set(key, _json.dumps(out, ensure_ascii=False), ex=3 * 24 * 3600)


async def _collect_memory_proposals(*, user_id: int, relationship_id: int | None, provider: str | None, thread_id: str) -> None:
  async with async_session_factory() as session:
    ai_session = await session.scalar(
      select(AISession).where(AISession.user_id == user_id, AISession.thread_id == thread_id)
    )
    if not ai_session:
      return
    context = ai_session.context_json or {}
    history: list[dict[str, str]] = [m for m in list(context.get('messages', [])) if (m.get('role') == 'user')]
    if not history:
      return
    recent_for_prefs = history[-max(1, settings.prefs_extract_window):]

    merged_prefs = await langchain_service.extract_and_merge_user_prefs(previous={}, messages=recent_for_prefs, provider=provider)
    merged_mems = await langchain_service.extract_and_merge_user_memories(previous={}, messages=recent_for_prefs, provider=provider)
    final_data: dict = {}
    if merged_prefs:
      final_data['prefs'] = merged_prefs
    if merged_mems:
      final_data['memories'] = merged_mems
    if final_data:
      await _merge_proposals(user_id=user_id, relationship_id=relationship_id, data=final_data)

TOOL_HINT_KEYWORDS = (
  '回顾', '最近', '篇', '日记', '纪念日', '生理期', '周期', '分析', '关键词', '总结', '提醒'
)

def _needs_tools(message: str) -> bool:
  text = (message or '').strip()
  if not text:
    return False
  # 简单启发式：出现关键字或包含数量/范围意图时尝试调用 Agent 工具
  if any(kw in text for kw in TOOL_HINT_KEYWORDS):
    return True
  return False

@router.post('/suggest')
async def suggest_next_step(
  payload: ai_schema.AISuggestionRequest,
  current_user: User = Depends(get_current_user),
) -> dict:
  """小蛋建议：按天缓存，默认每天早上 6 点刷新。
  Key 维度：thread=建议/日期/关系或用户。
  """
  redis = get_redis()
  rid = current_user.active_relationship_id

  # 计算缓存 key 和失效时间（到下一个 6 点）
  tz_local = timezone(timedelta(hours=8))
  local_now = datetime.now(tz_local)
  local_date = local_now.date()
  today6_local = datetime(local_date.year, local_date.month, local_date.day, 6, 0, 0, tzinfo=tz_local)
  if local_now < today6_local:
    cache_date = (local_date - timedelta(days=1)).strftime('%Y%m%d')
    next6_local = today6_local
  else:
    cache_date = local_date.strftime('%Y%m%d')
    next6_local = today6_local + timedelta(days=1)
  ttl = int((next6_local - local_now).total_seconds())
  ttl = max(ttl, 60)

  key = f"ai:suggest:{cache_date}:rid:{rid or 0}:uid:{current_user.id}"

  if redis:
    cached = await redis.get(key)
    if cached:
      try:
        import json as _json
        obj = _json.loads(cached)
        return success_response(obj)
      except Exception:
        pass

  suggestion = await langchain_service.suggest_next_step(
    context=payload.entry_context,
    mood=payload.mood,
    tags=payload.tags,
    provider=payload.provider,
  )
  if not suggestion:
    suggestion = 'AI service not configured, please check API keys.'
  response = ai_schema.AISuggestionResponse(message=suggestion, tags=payload.tags, prompt_id=None)
  data = response.model_dump(mode='json')

  if redis:
    try:
      import json as _json
      await redis.set(key, _json.dumps(data, ensure_ascii=False), ex=ttl)
    except Exception:
      pass

  return success_response(data)


async def _get_or_create_thread(
  *, thread_id: str | None, current_user: User, session: AsyncSession
) -> tuple[AISession, str, list[dict[str, str]], str | None]:
  effective_thread_id = thread_id or uuid4().hex

  query = select(AISession).where(AISession.thread_id == effective_thread_id).where(
    AISession.user_id == current_user.id
  )

  ai_session = await session.scalar(query)
  if not ai_session:
    ai_session = AISession(
      user_id=current_user.id,
      relationship_id=current_user.active_relationship_id,
      thread_id=effective_thread_id,
      context_json={'messages': [], 'summary': None},
    )
    session.add(ai_session)
    await session.commit()
    await session.refresh(ai_session)

  context = ai_session.context_json or {}
  history: list[dict[str, str]] = list(context.get('messages', []))
  summary: str | None = context.get('summary')
  return ai_session, effective_thread_id, history, summary


async def _extract_user_prefs_task(*, user_id: int, relationship_id: int | None, provider: str | None, thread_id: str) -> None:
  async with async_session_factory() as session:
    ai_session = await session.scalar(
      select(AISession).where(AISession.user_id == user_id, AISession.thread_id == thread_id)
    )
    if not ai_session:
      return
    context = ai_session.context_json or {}
    history: list[dict[str, str]] = [m for m in list(context.get('messages', [])) if (m.get('role') == 'user')]
    if not history:
      return
    recent_for_prefs = history[-max(1, settings.prefs_extract_window):]

    merged_prefs = await langchain_service.extract_and_merge_user_prefs(previous={}, messages=recent_for_prefs, provider=provider)
    merged_mems = await langchain_service.extract_and_merge_user_memories(previous={}, messages=recent_for_prefs, provider=provider)
    final_data = {}
    if merged_prefs:
      final_data['prefs'] = merged_prefs
    if merged_mems:
      final_data['memories'] = merged_mems
    # Sync to memory graph (nodes/edges)
    await _sync_memory_graph(
      session=session,
      user_id=user_id,
      relationship_id=relationship_id,
      data=final_data or {},
    )


async def _sync_memory_graph(*, session: AsyncSession, user_id: int, relationship_id: int | None, data: dict) -> None:
  # Helper upserts
  async def upsert_node(ntype: str, label: str, props: dict | None = None) -> int:
    props = props or {}
    node = await session.scalar(
      select(MemoryNode).where(
        MemoryNode.user_id == user_id,
        MemoryNode.relationship_id == relationship_id,
        MemoryNode.type == ntype,
        MemoryNode.label == label,
      )
    )
    if not node:
      node = MemoryNode(user_id=user_id, relationship_id=relationship_id, type=ntype, label=label, props_json=props)
    else:
      # merge props (shallow)
      base = dict(node.props_json or {})
      base.update(props)
      node.props_json = base
    session.add(node)
    await session.flush()
    return node.id

  async def upsert_edge(src_id: int, dst_id: int, etype: str, props: dict | None = None) -> None:
    props = props or {}
    edge = await session.scalar(
      select(MemoryEdge).where(
        MemoryEdge.user_id == user_id,
        MemoryEdge.relationship_id == relationship_id,
        MemoryEdge.type == etype,
        MemoryEdge.src_id == src_id,
        MemoryEdge.dst_id == dst_id,
      )
    )
    if not edge:
      edge = MemoryEdge(user_id=user_id, relationship_id=relationship_id, type=etype, src_id=src_id, dst_id=dst_id, props_json=props)
    else:
      base = dict(edge.props_json or {})
      base.update(props)
      edge.props_json = base
    session.add(edge)

  # person nodes：我/伴侣
  person_id = await upsert_node('person', '我', {})
  partner_id: int | None = None
  if relationship_id:
    partner_id = await upsert_node('person', '伴侣', {})
  
  # 辅助：类别节点（用于偏好分组）
  CATEGORY_LABELS: dict[str, str] = {
    'food': '食物',
    'gifts': '礼物',
    'activities': '活动',
    'pets': '宠物',
    'travel': '旅行',
    'schedule': '作息',
    'tone_preference': '语气偏好',
  }
  category_node_ids: dict[str, int] = {}
  async def get_category_node_id(key: str) -> int:
    if key in category_node_ids:
      return category_node_ids[key]
    label = CATEGORY_LABELS.get(key, key)
    cid = await upsert_node('category', label, {})
    category_node_ids[key] = cid
    return cid
  # Pre-scan partner items from explicit preferences to avoid mis-linking to self
  partner_items: set[str] = set()
  try:
    _mem0 = data.get('memories') if isinstance(data, dict) else {}
    _exp0 = _mem0.get('preferences') if isinstance(_mem0, dict) else []
    if isinstance(_exp0, list):
      for _p in _exp0:
        if isinstance(_p, dict) and (str(_p.get('subject') or '').strip() == 'partner'):
          it = str(_p.get('item') or '').strip()
          if it:
            partner_items.add(it)
  except Exception:
    pass

  # preferences
  prefs = data.get('prefs') if isinstance(data, dict) else {}
  def _iter_list(key: str) -> list[str]:
    val = prefs.get(key) if isinstance(prefs, dict) else []
    return [str(x).strip() for x in (val or []) if str(x).strip()]

  for it in _iter_list('likes'):
    if it in partner_items:
      continue
    pid = await upsert_node('preference', it, {'polarity': 'like'})
    await upsert_edge(person_id, pid, 'prefers', {'polarity': 'like'})
  for it in _iter_list('dislikes'):
    if it in partner_items:
      continue
    pid = await upsert_node('preference', it, {'polarity': 'dislike'})
    await upsert_edge(person_id, pid, 'avoids', {'polarity': 'dislike'})
  for it in _iter_list('topics'):
    tid = await upsert_node('topic', it, {})
    await upsert_edge(person_id, tid, 'mentions', {})

  # 结构化偏好：将 food/gifts/activities/pets/travel/schedule 等条目映射到 category 分组
  for key in ['food', 'gifts', 'activities', 'pets', 'travel', 'schedule', 'tone_preference']:
    items = _iter_list(key)
    if not items:
      continue
    cid = await get_category_node_id(key)
    # 我 -> 类别 分组连接，减少星状直连
    await upsert_edge(person_id, cid, 'has_category', {'category': key})
    for it in items:
      pid = await upsert_node('preference', it, {})
      # 偏好归类边：preference -> category
      await upsert_edge(pid, cid, 'belongs_to', {'category': key})

  # reminders
  mem = data.get('memories') if isinstance(data, dict) else {}
  # preferences with explicit subject (self|partner)
  explicit_prefs = mem.get('preferences') if isinstance(mem, dict) else []
  if isinstance(explicit_prefs, list):
    for p in explicit_prefs:
      if not isinstance(p, dict):
        continue
      item = str(p.get('item') or '').strip()
      if not item:
        continue
      polarity = (str(p.get('polarity') or '').strip() or 'like')
      subject = (str(p.get('subject') or '').strip() or 'self')
      # ensure partner node exists when needed
      target_person_id = person_id
      if subject == 'partner':
        if partner_id is None:
          partner_id = await upsert_node('person', '伴侣', {})
        target_person_id = partner_id
      pref_node_id = await upsert_node('preference', item, {'polarity': polarity})
      if polarity == 'dislike':
        await upsert_edge(target_person_id, pref_node_id, 'avoids', {'polarity': 'dislike'})
        # cleanup: if previously linked to self as avoids, remove it
        if target_person_id != person_id:
          old = await session.scalar(
            select(MemoryEdge).where(
              MemoryEdge.user_id == user_id,
              MemoryEdge.relationship_id == relationship_id,
              MemoryEdge.type == 'avoids',
              MemoryEdge.src_id == person_id,
              MemoryEdge.dst_id == pref_node_id,
            )
          )
          if old:
            await session.delete(old)
      else:
        await upsert_edge(target_person_id, pref_node_id, 'prefers', {'polarity': 'like'})
        if target_person_id != person_id:
          old = await session.scalar(
            select(MemoryEdge).where(
              MemoryEdge.user_id == user_id,
              MemoryEdge.relationship_id == relationship_id,
              MemoryEdge.type == 'prefers',
              MemoryEdge.src_id == person_id,
              MemoryEdge.dst_id == pref_node_id,
            )
          )
          if old:
            await session.delete(old)
      # 若 partner 偏好中出现明显类别关键词，尝试挂到类别
      # 简单启发式：根据关键词映射到类别
      keyword_map = {
        '吃': 'food', '餐': 'food', '味': 'food',
        '礼物': 'gifts', '送': 'gifts',
        '逛': 'activities', '玩': 'activities', '运动': 'activities',
        '宠物': 'pets', '猫': 'pets', '狗': 'pets',
        '旅': 'travel', '游': 'travel', '行': 'travel',
        '早': 'schedule', '晚': 'schedule', '作息': 'schedule',
      }
      for kw, cat in keyword_map.items():
        if kw in item:
          cid = await get_category_node_id(cat)
          await upsert_edge(pref_node_id, cid, 'belongs_to', {'category': cat})
          if target_person_id is not None:
            await upsert_edge(target_person_id, cid, 'has_category', {'category': cat})
          break
  reminders = mem.get('reminders') if isinstance(mem, dict) else []
  # Ensure relation edge between 我 and 伴侣
  if partner_id is not None:
    await upsert_edge(person_id, partner_id, 'partner_of', {})
  # Cleanup: remove any self->preference edges for partner items (both types)
  if partner_items:
    for _item in partner_items:
      _pref_id = await upsert_node('preference', _item, {})
      for _etype in ('prefers', 'avoids'):
        _old = await session.scalar(
          select(MemoryEdge).where(
            MemoryEdge.user_id == user_id,
            MemoryEdge.relationship_id == relationship_id,
            MemoryEdge.type == _etype,
            MemoryEdge.src_id == person_id,
            MemoryEdge.dst_id == _pref_id,
          )
        )
        if _old:
          await session.delete(_old)
  current_reminder_labels: set[str] = set()
  if isinstance(reminders, list):
    for r in reminders:
      if not isinstance(r, dict):
        continue
      text = str(r.get('text') or '').strip()
      if not text:
        continue
      current_reminder_labels.add(text)
      from uuid import uuid4 as _uuid4
      rid = await upsert_node('reminder', text, {
        'id': r.get('id') or _uuid4().hex,
        'due': r.get('due'),
        'tz': r.get('tz'),
        'status': r.get('status') or 'active',
      })
      await upsert_edge(person_id, rid, 'reminds', {})
      # 将提醒与相关偏好/话题建立关联（结巴分词 + 同义词归一）
      txt = text
      if txt:
        try:
          import jieba  # type: ignore
        except Exception:
          jieba = None
        tokens: set[str] = set()
        if jieba:
          for w in jieba.cut(txt):
            w = w.strip()
            if w:
              tokens.add(w)
        else:
          # 简单兜底：按中文字符切片与空格分割
          for ch in txt.replace(' ', ''):
            tokens.add(ch)
        # 同义词映射
        synonyms = {
          '榴莲': {'durian'},
          '香菜': {'芫荽', 'cilantro'},
          '花生': {'peanut', 'peanuts'},
          '咖啡': {'coffee'},
          '可乐': {'cola'},
        }
        norm_tokens = set(tokens)
        for k, vs in synonyms.items():
          if any(v in tokens for v in vs):
            norm_tokens.add(k)

        # 匹配现有的偏好/话题节点（用规范化）
        existing_pref_nodes = (await session.scalars(
          select(MemoryNode).where(
            MemoryNode.user_id == user_id,
            MemoryNode.relationship_id == relationship_id,
            MemoryNode.type.in_(['preference','topic']),
          )
        )).all()
        for pn in existing_pref_nodes:
          label = (pn.label or '').strip()
          if not label:
            continue
          if label in norm_tokens:
            await upsert_edge(rid, pn.id, 'relates_to', {})
  # soft-delete reminders that no longer exist in source data
  if current_reminder_labels:
    existing_reminder_nodes = (await session.scalars(
      select(MemoryNode).where(
        MemoryNode.user_id == user_id,
        MemoryNode.relationship_id == relationship_id,
        MemoryNode.type == 'reminder',
      )
    )).all()
    for n in existing_reminder_nodes:
      if n.label not in current_reminder_labels:
        props = dict(n.props_json or {})
        props['status'] = props.get('status') or 'deleted'
        n.props_json = props
        session.add(n)

  # agreements
  agreements = mem.get('agreements') if isinstance(mem, dict) else []
  if isinstance(agreements, list):
    for a in agreements:
      if not isinstance(a, dict):
        continue
      text = str(a.get('text') or '').strip()
      if not text:
        continue
      aid = await upsert_node('agreement', text, {'status': a.get('status') or 'active'})
      await upsert_edge(person_id, aid, 'agreed_with', {})

  # boundaries
  boundaries = mem.get('boundaries') if isinstance(mem, dict) else []
  if isinstance(boundaries, list):
    for b in boundaries:
      if not isinstance(b, dict):
        continue
      item = str(b.get('item') or '').strip()
      if not item:
        continue
      bid = await upsert_node('boundary', item, {'level': b.get('level') or ''})
      await upsert_edge(person_id, bid, 'has_boundary', {})
  await session.commit()

  # anniversaries upsert（持久化写入图谱）
  filters = [Anniversary.deleted_at.is_(None)]
  if relationship_id:
    filters.append(or_(Anniversary.relationship_id == relationship_id, Anniversary.user_id == user_id))
  else:
    filters.append(Anniversary.user_id == user_id)
  anns = (await session.scalars(select(Anniversary).where(*filters))).all()
  for a in anns:
    ann_node_id = await upsert_node('anniversary', a.name, {'date': str(a.date), 'is_lunar': a.is_lunar})
    await upsert_edge(person_id, ann_node_id, 'has_anniversary', {})
  await session.commit()


@router.post('/chat')
async def chat_with_ai(
  payload: ai_schema.AIChatRequest,
  background_tasks: BackgroundTasks,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  ai_session, thread_id, history, summary = await _get_or_create_thread(
    thread_id=payload.thread_id, current_user=current_user, session=session
  )

  history_for_prompt = history[-20:]
  # 更新指代缓存
  context = ai_session.context_json or {}
  context, assume_partner = _update_partner_referent(context, payload.message)

  # 始终通过 Agent 模式，由 Agent 自行决定是否调用工具；传入会话记忆
  reply = await love_notes_agent.arun(
    session=session,
    user=current_user,
    message=payload.message,
    provider=payload.provider,
    history=history_for_prompt,
    summary=summary,
  )
  if not reply:
    reply = 'AI service unavailable right now. Please try again later.'

  history.append({'role': 'user', 'content': payload.message})
  history.append({'role': 'assistant', 'content': reply})

  if len(history) > MAX_HISTORY_MESSAGES:
    overflow = len(history) - HISTORY_KEEP_MESSAGES
    to_compact = history[:overflow]
    summary = await langchain_service.summarize_history(previous=summary, new_messages=to_compact, provider=payload.provider)
    history = history[overflow:]

  ai_session.relationship_id = ai_session.relationship_id or current_user.active_relationship_id
  ai_session.context_json = {'messages': history, 'summary': summary, **context}
  session.add(ai_session)
  await session.commit()
  # 兜底规则：先用快速正则从单条消息直接写入（确保“我不喜欢榴莲”一说就记住）
  quick = _quick_extract_from_text(payload.message, assume_partner_pronoun=assume_partner)
  if quick:
    background_tasks.add_task(
      _sync_quick_memory,
      user_id=current_user.id,
      relationship_id=current_user.active_relationship_id,
      data=quick,
    )
  # 再触发 LLM 萃取（窗口聚合、更智能），两者互补
  background_tasks.add_task(
    _extract_user_prefs_task,
    user_id=current_user.id,
    relationship_id=current_user.active_relationship_id,
    provider=payload.provider,
    thread_id=thread_id,
  )

  response = ai_schema.AIChatResponse(thread_id=thread_id, reply=reply, relationship_id=ai_session.relationship_id)
  return success_response(response.model_dump(mode='json'))


@router.post('/chat/stream')
async def chat_with_ai_stream(
  payload: ai_schema.AIChatRequest,
  background_tasks: BackgroundTasks,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
):
  ai_session, thread_id, history, summary = await _get_or_create_thread(
    thread_id=payload.thread_id, current_user=current_user, session=session
  )
  history_for_prompt = history[-20:]
  # 更新指代缓存
  context = ai_session.context_json or {}
  context, assume_partner = _update_partner_referent(context, payload.message)

  async def event_stream():
    nonlocal summary
    collected: list[str] = []

    async for chunk in love_notes_agent.astream(
      session=session,
      user=current_user,
      message=payload.message,
      provider=payload.provider,
      history=history_for_prompt,
      summary=summary,
    ):
      if not chunk:
        continue
      parsed = None
      try:
        parsed = json.loads(chunk)
      except Exception:
        parsed = None
      if isinstance(parsed, dict) and parsed.get('type') == 'tool':
        yield f"data: {chunk}\n\n"
        continue

      collected.append(chunk)
      yield f"data: {chunk}\n\n"

    reply_text = ''.join(collected).strip()
    history.append({'role': 'user', 'content': payload.message})
    history.append({'role': 'assistant', 'content': reply_text})

    if len(history) > MAX_HISTORY_MESSAGES:
      overflow = len(history) - HISTORY_KEEP_MESSAGES
      to_compact = history[:overflow]
      summary_local = await langchain_service.summarize_history(previous=summary, new_messages=to_compact, provider=payload.provider)
      summary = summary_local
      history[:] = history[overflow:]

    ai_session.relationship_id = ai_session.relationship_id or current_user.active_relationship_id
    ai_session.context_json = {'messages': history, 'summary': summary, **context}
    session.add(ai_session)
    await session.commit()
    quick = _quick_extract_from_text(payload.message, assume_partner_pronoun=assume_partner)
    if quick:
      background_tasks.add_task(
        _sync_quick_memory,
        user_id=current_user.id,
        relationship_id=current_user.active_relationship_id,
        data=quick,
      )
    background_tasks.add_task(
      _extract_user_prefs_task,
      user_id=current_user.id,
      relationship_id=current_user.active_relationship_id,
      provider=payload.provider,
      thread_id=thread_id,
    )
    yield "data: [DONE]\n\n"

  return StreamingResponse(event_stream(), media_type='text/event-stream', headers={'X-Thread-Id': thread_id})


@router.get('/chat/history')
async def fetch_chat_history(
  limit: int = 10,
  offset: int = 0,
  thread_id: str | None = None,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  limit = max(1, min(limit, 50))
  offset = max(0, offset)

  query = select(AISession)
  if thread_id:
    query = query.where(AISession.thread_id == thread_id).where(AISession.user_id == current_user.id)
  else:
    query = query.where(AISession.user_id == current_user.id).order_by(desc(AISession.updated_at)).limit(1)

  ai_session = await session.scalar(query)
  if not ai_session:
    return success_response({'thread_id': thread_id, 'messages': [], 'has_more': False, 'summary': None, 'next_offset': None})

  context = ai_session.context_json or {}
  history: list[dict[str, str]] = list(context.get('messages', []))
  summary = context.get('summary')
  total = len(history)

  if total == 0:
    return success_response({'thread_id': ai_session.thread_id, 'messages': [], 'has_more': bool(summary), 'summary': summary, 'next_offset': None})

  end = total - offset if offset < total else 0
  start = max(end - limit, 0)
  slice_messages = history[start:end]
  has_more = start > 0 or (bool(summary) and start == 0)
  next_offset = offset + len(slice_messages) if has_more else None

  return success_response(
    {
      'thread_id': ai_session.thread_id,
      'messages': slice_messages,
      'has_more': has_more,
      'summary': summary,
      'next_offset': next_offset,
      'total': total,
    }
  )


@router.get('/memory/proposals')
async def get_memory_proposals(
  current_user: User = Depends(get_current_user),
):
  redis = get_redis()
  if not redis:
    return success_response({'proposals': None})
  raw = await redis.get(_proposal_key(current_user.id, current_user.active_relationship_id))
  if not raw:
    return success_response({'proposals': None})
  try:
    data = json.loads(raw)
  except Exception:
    data = None
  return success_response({'proposals': data})


@router.post('/memory/proposals/commit')
async def commit_memory_proposals(
  payload: dict,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
):
  # payload: { likes?: [str], dislikes?: [str], topics?: [str], reminders?: [str], agreements?: [str], boundaries?: [str] }
  accepted = {k: list(v or []) for k, v in (payload or {}).items() if isinstance(v, list)}
  data: dict = {'prefs': {}, 'memories': {'reminders': [], 'agreements': [], 'boundaries': []}}
  if accepted.get('likes'): data['prefs']['likes'] = accepted['likes']
  if accepted.get('dislikes'): data['prefs']['dislikes'] = accepted['dislikes']
  if accepted.get('topics'): data['prefs']['topics'] = accepted['topics']
  for txt in accepted.get('reminders') or []:
    data['memories']['reminders'].append({'text': txt, 'due': ''})
  for txt in accepted.get('agreements') or []:
    data['memories']['agreements'].append({'text': txt, 'status': 'active'})
  for it in accepted.get('boundaries') or []:
    data['memories']['boundaries'].append({'item': it, 'level': 'medium'})
  await _sync_memory_graph(session=session, user_id=current_user.id, relationship_id=current_user.active_relationship_id, data=data)
  # 清空提案
  redis = get_redis()
  if redis:
    await redis.delete(_proposal_key(current_user.id, current_user.active_relationship_id))
  return success_response({'ok': True})


@router.post('/memory/proposals/clear')
async def clear_memory_proposals(
  current_user: User = Depends(get_current_user),
):
  redis = get_redis()
  if redis:
    await redis.delete(_proposal_key(current_user.id, current_user.active_relationship_id))
  return success_response({'ok': True})

@router.post('/assistant')
async def assistant_agent(
  payload: ai_schema.AIAssistantRequest,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  reply = await love_notes_agent.arun(
    session=session,
    user=current_user,
    message=payload.message,
    provider=payload.provider,
  )
  response = ai_schema.AIAssistantResponse(reply=reply)
  return success_response(response.model_dump(mode='json'))


@router.get('/memory')
async def get_ai_memory(
  include_expired: bool = False,
  upcoming_days: int = 30,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  # Fetch user preference container (relationship-level preferred; optional fallback)
  pref = await session.scalar(
    select(UserPreference)
    .where(UserPreference.user_id == current_user.id)
    .where(UserPreference.relationship_id == current_user.active_relationship_id)
  )
  source = 'relationship'
  if not pref and not settings.prefs_strict_relation:
    pref = await session.scalar(
      select(UserPreference)
      .where(UserPreference.user_id == current_user.id)
      .where(UserPreference.relationship_id.is_(None))
    )
    source = 'user' if pref else 'none'
  if not pref:
    return success_response({'memories': {'facts': {}, 'agreements': [], 'reminders': [], 'boundaries': [], 'notes': []}, 'source': 'none'})

  data = pref.data_json or {}
  memories = data.get('memories') or {}
  reminders = list(memories.get('reminders') or [])

  # Filter reminders at read time (no scheduler): drop expired unless requested; limit upcoming range
  now = datetime.now(timezone(timedelta(hours=8)))  # default to UTC+8 if no tz provided
  filtered: list[dict] = []
  horizon = now + timedelta(days=max(0, int(upcoming_days)))

  def _parse_due(s: str | None):
    if not s or not isinstance(s, str):
      return None
    try:
      # Expect ISO8601 with optional offset
      return datetime.fromisoformat(s)
    except Exception:
      return None

  for r in reminders:
    if not isinstance(r, dict):
      continue
    due_raw = r.get('due')
    due_dt = _parse_due(due_raw)
    status = str(r.get('status') or '').strip().lower() or 'active'
    keep = True
    if due_dt is not None:
      is_expired = due_dt < now
      if is_expired and not include_expired:
        keep = False
      # upcoming horizon filter (only for non-expired or when include_expired is False)
      if not is_expired and upcoming_days is not None and due_dt > horizon:
        keep = False
      # If we include expired, mark status in response
      if include_expired and is_expired and status == 'active':
        r = {**r, 'status': 'expired'}
    if keep:
      filtered.append(r)

  result = {
    'memories': {
      'facts': memories.get('facts') or {},
      'agreements': memories.get('agreements') or [],
      'reminders': filtered,
      'boundaries': memories.get('boundaries') or [],
      'notes': memories.get('notes') or [],
    },
    'source': source,
    'now': now.isoformat(),
  }
  return success_response(result)


@router.get('/graph')
async def get_ai_graph(
  types: str | None = None,
  q: str | None = None,
  include_expired: bool = False,
  upcoming_days: int = 30,
  limit: int = 500,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  type_list = None
  if types:
    type_list = [t.strip() for t in types.split(',') if t.strip()]
  # Fetch nodes scoped to user/relationship
  q_nodes = select(MemoryNode).where(MemoryNode.user_id == current_user.id)
  if current_user.active_relationship_id is not None:
    q_nodes = q_nodes.where(MemoryNode.relationship_id == current_user.active_relationship_id)
  if type_list:
    q_nodes = q_nodes.where(MemoryNode.type.in_(type_list))
  if q:
    like = f"%{q}%"
    from sqlalchemy import literal
    # SQLAlchemy 2.0 style .like
    q_nodes = q_nodes.where(MemoryNode.label.like(like))
  q_nodes = q_nodes.limit(max(10, min(2000, limit)))
  nodes = (await session.scalars(q_nodes)).all()

  # Read-time filter reminders by due/status
  now = datetime.now(timezone(timedelta(hours=8)))
  horizon = now + timedelta(days=max(0, int(upcoming_days)))
  def _keep_node(n: MemoryNode) -> bool:
    if n.type != 'reminder':
      return True
    props = n.props_json or {}
    status = str(props.get('status') or '').strip().lower()
    # 隐藏已完成/已删除的提醒
    if status in ('done', 'deleted'):
      return False
    due_s = props.get('due')
    try:
      due_dt = datetime.fromisoformat(due_s) if isinstance(due_s, str) else None
    except Exception:
      due_dt = None
    if due_dt is None:
      return True
    is_expired = due_dt < now
    if is_expired and not include_expired:
      return False
    if not is_expired and upcoming_days is not None and due_dt > horizon:
      return False
    return True

  node_ids = [n.id for n in nodes if _keep_node(n)]
  id_set = set(node_ids)
  # Edges among included nodes
  q_edges = select(MemoryEdge).where(MemoryEdge.user_id == current_user.id)
  if current_user.active_relationship_id is not None:
    q_edges = q_edges.where(MemoryEdge.relationship_id == current_user.active_relationship_id)
  edges = (await session.scalars(q_edges)).all()
  edges = [e for e in edges if e.src_id in id_set and e.dst_id in id_set]

  def _pack_node(n: MemoryNode) -> dict:
    return {
      'id': n.id,
      'type': n.type,
      'label': n.label,
      'props': n.props_json or {},
    }
  def _pack_edge(e: MemoryEdge) -> dict:
    return {
      'id': e.id,
      'type': e.type,
      'src': e.src_id,
      'dst': e.dst_id,
      'props': e.props_json or {},
    }

  packed_nodes = [_pack_node(n) for n in nodes if n.id in id_set]
  packed_edges = [_pack_edge(e) for e in edges]

  # Anniversaries as dynamic nodes (when requested or when no type filter)
  include_ann = (not type_list) or ('anniversary' in (type_list or []))
  if include_ann:
    filters = [Anniversary.deleted_at.is_(None)]
    rid = current_user.active_relationship_id
    if rid:
      filters.append(or_(Anniversary.relationship_id == rid, Anniversary.user_id == current_user.id))
    else:
      filters.append(Anniversary.user_id == current_user.id)
    anns = (await session.scalars(select(Anniversary).where(*filters))).all()
    # Read-time filter similar to reminders (by date)
    today = now.date()
    from app.services.lunar_utils import parse_lunar_str, next_solar_from_lunar
    for a in anns:
      # 计算“下一次发生日”（支持农历）用于过滤和展示
      occ = None
      if a.is_lunar and a.lunar_date_str:
        patt = parse_lunar_str(a.lunar_date_str)
        if patt:
          occ = next_solar_from_lunar(patt, today)
      if not occ:
        # fallback: 基于存储的公历日期年复用
        base = a.date
        occ = base.replace(year=today.year)
        if occ < today:
          occ = occ.replace(year=today.year + 1)

      if not occ:
        continue
      if not include_expired and occ < today:
        continue
      if upcoming_days is not None and occ > (today + timedelta(days=max(0, int(upcoming_days)))):
        continue
      # ensure person node exists in response
      if not any(n['type'] == 'person' and n['label'] == '我' for n in packed_nodes):
        packed_nodes.append({'id': -1, 'type': 'person', 'label': '我', 'props': {}})
        id_set.add(-1)
      ann_id = -(100000 + a.id)  # negative id to avoid collision
      days_until = (occ - today).days
      packed_nodes.append({'id': ann_id, 'type': 'anniversary', 'label': f"{a.name}", 'props': {'date': occ.isoformat(), 'is_lunar': a.is_lunar, 'days_until': days_until}})
      packed_edges.append({'id': ann_id, 'type': 'has_anniversary', 'src': -1, 'dst': ann_id, 'props': {}})

  result = {
    'nodes': packed_nodes,
    'edges': packed_edges,
    'now': now.isoformat(),
    'filters': {'types': type_list, 'q': q, 'include_expired': include_expired, 'upcoming_days': upcoming_days},
  }
  return success_response(result)


# ====== 简易提醒 CRUD（基于 user_preferences.data_json，读时过滤，无需定时任务） ======

@router.post('/memory/reminders')
async def create_reminder(
  payload: dict,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  text = str(payload.get('text') or '').strip()
  due = payload.get('due')
  tz = payload.get('tz')
  if not text:
    return success_response({'ok': False, 'message': 'text 不能为空'})

  # 图谱写入
  person = await session.scalar(
    select(MemoryNode).where(
      MemoryNode.user_id == current_user.id,
      MemoryNode.relationship_id == current_user.active_relationship_id,
      MemoryNode.type == 'person',
      MemoryNode.label == 'self',
    )
  )
  if not person:
    person = MemoryNode(user_id=current_user.id, relationship_id=current_user.active_relationship_id, type='person', label='self', props_json={})
    session.add(person)
    await session.flush()
  rid = uuid4().hex
  node = MemoryNode(user_id=current_user.id, relationship_id=current_user.active_relationship_id, type='reminder', label=text, props_json={'id': rid, 'due': due, 'tz': tz, 'status': 'active'})
  session.add(node)
  await session.flush()
  session.add(MemoryEdge(user_id=current_user.id, relationship_id=current_user.active_relationship_id, type='reminds', src_id=person.id, dst_id=node.id, props_json={}))
  await session.commit()
  return success_response({'ok': True, 'id': rid})


@router.post('/memory/reminders/{rid}/done')
async def complete_reminder(
  rid: str,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  nodes = (await session.scalars(
    select(MemoryNode).where(
      MemoryNode.user_id == current_user.id,
      MemoryNode.relationship_id == current_user.active_relationship_id,
      MemoryNode.type == 'reminder',
    )
  )).all()
  target = None
  for n in nodes:
    if isinstance(n.props_json, dict) and n.props_json.get('id') == rid:
      target = n
      break
    if str(n.id) == rid:
      target = n
      break
  if not target:
    return success_response({'ok': False, 'message': '未找到提醒'})
  props = dict(target.props_json or {})
  props['status'] = 'done'
  target.props_json = props
  session.add(target)
  await session.commit()
  return success_response({'ok': True})


@router.post('/memory/reminders/{rid}/snooze')
async def snooze_reminder(
  rid: str,
  payload: dict,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  new_due = payload.get('due')
  if not isinstance(new_due, str) or not new_due.strip():
    return success_response({'ok': False, 'message': '缺少新的 due'})
  nodes = (await session.scalars(
    select(MemoryNode).where(
      MemoryNode.user_id == current_user.id,
      MemoryNode.relationship_id == current_user.active_relationship_id,
      MemoryNode.type == 'reminder',
    )
  )).all()
  target = None
  for n in nodes:
    if isinstance(n.props_json, dict) and n.props_json.get('id') == rid:
      target = n
      break
    if str(n.id) == rid:
      target = n
      break
  if not target:
    return success_response({'ok': False, 'message': '未找到提醒'})
  props = dict(target.props_json or {})
  props['due'] = new_due
  props['status'] = 'active'
  target.props_json = props
  session.add(target)
  await session.commit()
  return success_response({'ok': True})


@router.delete('/memory/reminders/{rid}')
async def delete_reminder(
  rid: str,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  nodes = (await session.scalars(
    select(MemoryNode).where(
      MemoryNode.user_id == current_user.id,
      MemoryNode.relationship_id == current_user.active_relationship_id,
      MemoryNode.type == 'reminder',
    )
  )).all()
  target = None
  for n in nodes:
    if isinstance(n.props_json, dict) and n.props_json.get('id') == rid:
      target = n
      break
    if str(n.id) == rid:
      target = n
      break
  if not target:
    return success_response({'ok': False, 'message': '未找到提醒'})
  props = dict(target.props_json or {})
  props['status'] = 'deleted'
  target.props_json = props
  session.add(target)
  await session.commit()
  return success_response({'ok': True})


# ====== agreements/boundaries CRUD （图谱写入）======

@router.post('/memory/agreements')
async def create_agreement(
  payload: dict,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  text = str(payload.get('text') or '').strip()
  status = str(payload.get('status') or 'active').strip()
  if not text:
    return success_response({'ok': False, 'message': 'text 不能为空'})
  person = await session.scalar(
    select(MemoryNode).where(
      MemoryNode.user_id == current_user.id,
      MemoryNode.relationship_id == current_user.active_relationship_id,
      MemoryNode.type == 'person',
      MemoryNode.label == 'self',
    )
  )
  if not person:
    person = MemoryNode(user_id=current_user.id, relationship_id=current_user.active_relationship_id, type='person', label='self', props_json={})
    session.add(person)
    await session.flush()
  aid = uuid4().hex
  node = MemoryNode(user_id=current_user.id, relationship_id=current_user.active_relationship_id, type='agreement', label=text, props_json={'id': aid, 'status': status})
  session.add(node)
  await session.flush()
  session.add(MemoryEdge(user_id=current_user.id, relationship_id=current_user.active_relationship_id, type='agreed_with', src_id=person.id, dst_id=node.id, props_json={}))
  await session.commit()
  return success_response({'ok': True, 'id': aid})


@router.post('/memory/agreements/{aid}')
async def update_agreement(
  aid: str,
  payload: dict,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  nodes = (await session.scalars(
    select(MemoryNode).where(
      MemoryNode.user_id == current_user.id,
      MemoryNode.relationship_id == current_user.active_relationship_id,
      MemoryNode.type == 'agreement',
    )
  )).all()
  target = None
  for n in nodes:
    if isinstance(n.props_json, dict) and n.props_json.get('id') == aid:
      target = n
      break
  if not target:
    return success_response({'ok': False, 'message': '未找到约定'})
  text = payload.get('text')
  status = payload.get('status')
  if isinstance(text, str) and text.strip():
    target.label = text.strip()
  props = dict(target.props_json or {})
  if isinstance(status, str) and status.strip():
    props['status'] = status.strip()
  target.props_json = props
  session.add(target)
  await session.commit()
  return success_response({'ok': True})


@router.delete('/memory/agreements/{aid}')
async def delete_agreement(
  aid: str,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  nodes = (await session.scalars(
    select(MemoryNode).where(
      MemoryNode.user_id == current_user.id,
      MemoryNode.relationship_id == current_user.active_relationship_id,
      MemoryNode.type == 'agreement',
    )
  )).all()
  target = None
  for n in nodes:
    if isinstance(n.props_json, dict) and n.props_json.get('id') == aid:
      target = n
      break
  if not target:
    return success_response({'ok': False, 'message': '未找到约定'})
  props = dict(target.props_json or {})
  props['status'] = 'deleted'
  target.props_json = props
  session.add(target)
  await session.commit()
  return success_response({'ok': True})


@router.post('/memory/boundaries')
async def create_boundary(
  payload: dict,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  item = str(payload.get('item') or '').strip()
  level = str(payload.get('level') or '').strip()
  if not item:
    return success_response({'ok': False, 'message': 'item 不能为空'})
  person = await session.scalar(
    select(MemoryNode).where(
      MemoryNode.user_id == current_user.id,
      MemoryNode.relationship_id == current_user.active_relationship_id,
      MemoryNode.type == 'person',
      MemoryNode.label == 'self',
    )
  )
  if not person:
    person = MemoryNode(user_id=current_user.id, relationship_id=current_user.active_relationship_id, type='person', label='self', props_json={})
    session.add(person)
    await session.flush()
  bid = uuid4().hex
  node = MemoryNode(user_id=current_user.id, relationship_id=current_user.active_relationship_id, type='boundary', label=item, props_json={'id': bid, 'level': level})
  session.add(node)
  await session.flush()
  session.add(MemoryEdge(user_id=current_user.id, relationship_id=current_user.active_relationship_id, type='has_boundary', src_id=person.id, dst_id=node.id, props_json={}))
  await session.commit()
  return success_response({'ok': True, 'id': bid})


@router.post('/memory/boundaries/{bid}')
async def update_boundary(
  bid: str,
  payload: dict,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  nodes = (await session.scalars(
    select(MemoryNode).where(
      MemoryNode.user_id == current_user.id,
      MemoryNode.relationship_id == current_user.active_relationship_id,
      MemoryNode.type == 'boundary',
    )
  )).all()
  target = None
  for n in nodes:
    if isinstance(n.props_json, dict) and n.props_json.get('id') == bid:
      target = n
      break
  if not target:
    return success_response({'ok': False, 'message': '未找到边界'})
  item = payload.get('item')
  level = payload.get('level')
  if isinstance(item, str) and item.strip():
    target.label = item.strip()
  props = dict(target.props_json or {})
  if isinstance(level, str) and level.strip():
    props['level'] = level.strip()
  target.props_json = props
  session.add(target)
  await session.commit()
  return success_response({'ok': True})


@router.delete('/memory/boundaries/{bid}')
async def delete_boundary(
  bid: str,
  current_user: User = Depends(get_current_user),
  session: AsyncSession = Depends(get_db),
) -> dict:
  nodes = (await session.scalars(
    select(MemoryNode).where(
      MemoryNode.user_id == current_user.id,
      MemoryNode.relationship_id == current_user.active_relationship_id,
      MemoryNode.type == 'boundary',
    )
  )).all()
  target = None
  for n in nodes:
    if isinstance(n.props_json, dict) and n.props_json.get('id') == bid:
      target = n
      break
  if not target:
    return success_response({'ok': False, 'message': '未找到边界'})
  props = dict(target.props_json or {})
  props['status'] = 'deleted'
  target.props_json = props
  session.add(target)
  await session.commit()
  return success_response({'ok': True})
  # preferences from memories（subject aware）
  prefs_mem = mem.get('preferences') if isinstance(mem, dict) else []
  if isinstance(prefs_mem, list):
    for p in prefs_mem:
      if not isinstance(p, dict):
        continue
      item = str(p.get('item') or '').strip()
      pol = str(p.get('polarity') or '').strip().lower()
      subj = str(p.get('subject') or '').strip().lower()
      if not item or pol not in ('like','dislike'):
        continue
      owner = person_id if subj != 'partner' else (partner_id or person_id)
      pref_node_id = await upsert_node('preference', item, {'polarity': pol})
      await upsert_edge(owner, pref_node_id, 'prefers' if pol == 'like' else 'avoids', {'polarity': pol})
